#!/usr/bin/env python
# vim:fileencoding=utf-8 shiftwidth=2 tabstop=2 expandtab
'''= CEDU authentication service =
We use Google groups as membership lists, to send mailings, and to grant users
permission to view and edit documents and web pages. Though any email address
can be used to join a Google group, members need to use an email address
registered with a Google account in order to be recognized and given permissions.

We need to be kept current with the membership lists for each Google group so
we can advise someone who is signed in to a non-member account. At this time
we're prohibited from reading Google group lists programmatically.

As needed, post the exported CSV member list from the group to 

We may implement the following method later -
+--------------------------------------------------------------------------------
| We'll look for membership lists in Google spreadsheets, currently in
| dolsson@collaborative.org's documents, named "Members for group <id>", where
| id is the group identifier used in the group web address and email address.
| The TD3 group's identifier is cedu-td3, its web address being
| http://groups.google.com/group/cedu-td3.  Create such a spreadsheet by
| exporting the member list from the group as a CSV file, then uploading the CSV
| file to Google docs. Copy the contents of the first cell in the spreadsheet and
| use it as the document name.
+--------------------------------------------------------------------------------
'''

# Import extra Python facilities.
import os
import time
import datetime
import logging
import re
import urllib
import urllib2
import httplib
import wsgiref.handlers
import csv
# Import Google App Engine facilities.
from google.appengine.ext import webapp
from google.appengine.ext import db
from google.appengine.api import users
from google.appengine.api import mail
from google.appengine.ext.webapp.util import run_wsgi_app


emergency_email = '4135306689@vtext.com'  # SMS (text message) to David Olsson's cell phone


# Determine our operating environment, to determine URL's.
if os.environ['SERVER_SOFTWARE'][:11] == 'Development':
  environment = 'SDK'
else:
  environment = 'app-engine'


class GroupMember(db.Model):
  '''A member of a group.
  The group name should be the domain and path of a Google group, 
  e.g., 'groups.google.com/group/cedutd3'.
  A member is denoted by a primary email address, zero or more 
  secondary email addresses, and a name.
  '''
  group = db.StringProperty(required=True)   # 
  email_address = db.StringProperty()   # member email address
  name = db.StringProperty()  # member name


class MemberCheck(webapp.RequestHandler):
  '''Check a user's membership in a group.
  We receive a POST request with a URL path (beyond the host name) of '/member/'
  plus the group identifer. The user's email address is an 'email' query parameter.
  For example:
  http://ceduauth.appspot.com/member/groups.google.com/group/cedu-td3?email=davidolsson%40gmail.com
  Possible response codes and text entities:
  404 Group <specified group> not found.
  400 Email address not specified.
  200 <blank body>  Indicates the email address isn't subscribed to the group, according to our records.
  200 <email address>  Returns the primary email address for the group member, which may differ from the email address supplied.
  '''
  '''Test cases:
  Nonexistent group.
  Missing email address.
  Malformed email address.
  Nonmember email address -> SMS text to phone.
  Member email address.
  New member email address (not yet loaded to app engine).
  '''

  def get(self):
    self.post()

  def post(self):
    # We'll return plain text.
    self.response.headers['Content-Type'] = 'text/plain'

    # The group is the part of the URL path after '/member/'
    # and not including a final /.
    group = self.request.path[8:].rstrip('/')

    # Member email address is the 'email' parameter.
    email_address = self.request.get('email')
    email_address = email_address.lower()  # for comparison
    if not email_address:
      self.error(400)  # Bad Request
      self.response.out.write('Your request requires an email parameter.')
      return

    # Look for this group member.
    member = GroupMember.all()
    member.filter('group =', group)
    member.filter('email_address =', email_address)
    mrecord = member.fetch(1)
    if mrecord:
      # Membership verified.
      self.response.out.write(email_address)
    else:
      # Do we have any records for this group?
      member = GroupMember.all()
      member.filter('group =', group)
      mrecord = member.fetch(1)
      if mrecord:
        # Yes, so return empty page, signifying "not a member".
        self.response.clear()
        # And send an email alert.
        SendEmail(to = emergency_email, subject = 'Not a Member', body = '<' + email_address + '> is not a member of "' + group + '".')
      else:
        # Return 404 - group not found
        self.error(404)  # Not Found
        msg = "Group not found: " + group
        self.response.out.write(msg)
        logging.warning(msg)


class IncomingGroupList(webapp.RequestHandler):
  """Receive a POSTed group email list.
  We expect the exact document that the Google group exported.
  First line = "Members for group <id>"
    where <id> is the URL tag and email name of the group, e.g., cedu-td3.
  Second line contains column headers separated by commas:
    email address,nickname,group status,email status,email preference,posting permissions,join year,join month,join day,join hour,join minute,join second
  Additional lines contain values separated by commas. Nicknames are enclosed in double quotes.
    dolsson5@gmail.com,"David Olsson",owner,,email,moderated,2009,10,14,18,44,51
  Characters are encoded in UTF-8 format.
  IMPORTANT: Accept only member records where 'group status' is 'member' or 'owner'.
  Random people may be in the list, marked 'pending'.

  After storing the new member list, respond with status 200.
  If the posted data is not recognizable, respond with status 400 and an explanation.
  """

  # Assume the group is a regular Google group, not a private Google Apps domain group.
  group_location = 'groups.google.com/group/'  # add the group ID to the end of this
  expected_headers = ["email address","nickname","group status","email status","email preference","posting permissions","join year","join month","join day","join hour","join minute","join second"]

  def get(self):
    '''Supply the upload interface. Make sure only an administrator can access this interface.'''
    form_tag = {'SDK': '<form action="http://localhost:8080/group" method="post" enctype="multipart/form-data">',
        'app-engine': '<form action="https://ceduauth.appspot.com/group" method="post" enctype="multipart/form-data">'}
    self.response.out.write('''
    <html>
      <head>
        <title>Upload group list to ceduauth</title>
      </head>
      <body>
        <h1>Update group member lists</h1>''' + form_tag[environment] + '''
          Submit a member list, a CSV file exported from a Google group.<br/><br/>
          <input type="file" name="file" size=120 /><br/>
          <input type="submit" /><br/>
        </form>
      </body>
    </html>
    ''')

  def post(self):
    # [Check the encoding of the body text?]
    # The file should arrive in MIME multipart format, like this:
    # ------WebKitFormBoundaryXfAv2BgKo01SyKN9
    # Content-Disposition: form-data; name="file"; filename="cedu-td3.csv"
    # Content-Type: application/octet-stream
    # 
    # Members for group cedu-td3
    # email address,nickname,group status,email status,email preference,posting permissions,join year,join month,join day,join hour,join minute,join second
    # dolsson5@gmail.com,"David Olsson",owner,,email,moderated,2009,10,14,18,44,51
    # dolsson@collaborative.org,"David Olsson",member,,no email,moderated,2009,11,10,11,18,13
    # ddouglas@collaborative.org,"",member,,email,moderated,2009,10,19,12,51,47
    # 
    # ------WebKitFormBoundaryXfAv2BgKo01SyKN9--

    lines = self.request.body_file.readlines()
    # Capture the end-of-line sequence, at least for the MIME packaging lines.
    eol = re.search('[\n\r]*$', lines[0]).group()
    # Slice the MIME wrapping off.
    lines = lines[lines.index(eol) + 1 : -2]
    # Use a CSV reader to split the fields in each row.
    csv_row = csv.reader(lines)
    # Read the title from the first line. Extract the group ID.
    title = csv_row.next()[0]
    group_id = title.rsplit(' ', 1)[1]
    group = self.group_location + group_id  # full domain/path to group
    self.response.out.write("Working on " + group + "<br/>")
    # Read column headers.
    headers = csv_row.next()
    if headers != self.expected_headers:
      self.error(400)  # "bad request"
      self.response.out.write("Problem: the header labels in the second line don't match what I was expecting.<br/><br/>")
      self.response.out.write("Expected: " + str(self.expected_headers) + "<br/><br/>")
      self.response.out.write("Received: " + str(headers) + "<br/><br/>")
      self.response.out.write("No data loaded.<br/>")
      logging.warning("Bad column headers for member list posted for group " + group_id)
      logging.warning("Start of bad headers: " + str(headers)[:60])
      return
    # Collect old members for removal.
    old_keys = GroupMember.all(keys_only=True).filter("group =", group).fetch(2000)
    # Read data lines, create member objects.
    new_members = []
    for values in csv_row:
      if values[0]:
        email_address = values[0]
        nickname = unicode(values[1], "utf-8")
        new_members.append(GroupMember(key_name = group + "/" + email_address, group = group, email_address = email_address, name = nickname))
    # Must have at least one member, with a sensible email address.
    if len(new_members) > 0 and new_members[0].email_address.find('@') > -1:
      # Remove old members.
      for k in old_keys:
        db.delete(k)
      # Store new members.
      new_keys = db.put(new_members)
      self.response.out.write("Removed " + str(len(old_keys)) + " old member entries.<br/>")
      self.response.out.write("Inserted " + str(len(new_keys)) + " new member entries:<br/><br/>")
      for k in new_keys:
        member = db.get(k)
        self.response.out.write(member.email_address + " " + member.name + "<br/>")
    else:
      self.error(400)  # "bad request"
      self.response.out.write("Member values missing or unrecognizable.")
    # Offer to do it again.
    form_tag = {'SDK': '<form action="http://localhost:8080/group" method="get">',
        'app-engine': '<form action="https://ceduauth.appspot.com/group" method="get">'}
    self.response.out.write('<hr/>' + form_tag[environment] + '''
      <input type="submit" name="go" value="Upload another file"/>
    </form>
    ''')


class RemoteConsole(webapp.RequestHandler):
  'For debugging and administration, interpret Python suites in place, via a form.'

  def get(self):
    'A Python console.'
    self.response.out.write('<html><body>')
    self.response.out.write("<code>GoogleGroup('groups.google.com/group/cedu-td3', 'dolsson5@gmail.com')<br/>")
    self.response.out.write(self.request.url)
    self.response.out.write('<br/><code>')
    self.response.out.write('<form action="' + self.request.url + 'post" method="post" enctype="text/plain">')
    self.response.out.write('<textarea name="text" rows="20" cols="80">')
    # Preprint some useful python.
    self.response.out.write("print 'Content-Type: text/plain'\n")
    self.response.out.write("print ''\n")
    self.response.out.write("td3=GoogleGroup('groups.google.com/group/cedu-td3', 'dolsson5@gmail.com')\n")
    self.response.out.write("print 'group ', td3.group\n")
    self.response.out.write("print 'group mothers ', db.Query(GroupMother).count()\n")
    self.response.out.write("print 'group members ', db.Query(GroupMember).count()\n")
    self.response.out.write('</textarea><br/>')
    self.response.out.write('<input type="submit" />')
    self.response.out.write('</form>')
    self.response.out.write('</body></hrml>')

  def post(self):
    'Interpret python code!'
    # Yes, we require you to be signed in as an administrator to access this handler.
    # Remove 'text=', change non-unix line endings to unix line endings, decode, and execute.
    text = self.request.body
    text = text[5:]
    # Chrome quotes the text, including replacing spaces with +'s. Firefox sends it plain.
    if not (' ' in text):
      text = urllib.unquote_plus(text)
    text = text.replace('\r\n', '\n')  # DOS -> Unix
    text = text.replace('\r', '\n')  # Mac -> Unix
    exec text


class EmailAnswer(webapp.RequestHandler):
  def post(self):
    answer = mail.InboundEmailMessage(self.request.body)
    SendEmail(subject = 'Answer', body = answer)


class SendEmail(mail.EmailMessage):
  def __init__(self, 
      sender = 'dolsson5@gmail.com', 
      to = 'dolsson5@gmail.com', 
      subject = 'ceduauth.appspot.com',
      body = 'Message from ceduauth.appspot.com.'):
    self.sender = sender
    self.to = to
    self.subject = subject
    self.body = body
    self.send()



class cedu_td3_token(db.Model):
  string = db.StringProperty(required=True)


class AuthSub(webapp.RequestHandler):
  'Authenticate for access to spreadsheets.'
  scope = 'http://spreadsheets.google.com/feeds'

  def get(self):
    'Generate an AuthSub token.'
    token = self.request.get('token')
    if token:  # user has approved, we have a token
      self.response.out.write('<html><body><pre>')
      self.response.out.write('One-time token: ' + token + '\n')
      # Convert to a session (long lasting) token.
      request = urllib2.Request('https://www.google.com/accounts/AuthSubSessionToken')
      request.add_header('Authorization', 'AuthSub token="' + token + '"')
      response = urllib2.urlopen(request)
      if response.code == 200:
        session_token = response.readline().replace('Token=', '', 1).replace('\n', '')
        self.response.out.write('Session token: ' + session_token + '\n')
        db.delete(db.Query(cedu_td3_token).fetch(999))
        cedu_td3_token(string = session_token).put()
        self.response.out.write('Session token stored. ')
      else:
        self.response.out.write('<html><body><pre>')
        self.response.out.write('Problem attempting to convert token to session token,\n')
        self.response.out.write('HTTP status: ' + str(response.code) + ', ' + response.status + '\n')
        self.response.out.write(str(response) + '\n------------------\n')
        self.response.out.write(response.read())
      self.response.out.write('</pre></body></html')
      return
    else:  # redirect to user authorization page
      params = urllib.urlencode({
        'next' : self.request.uri,
        'scope' : self.scope,
        'hd' : 'collaborative.org',
        'secure' : 0,
        'session' : 1})
      self.redirect('https://www.google.com/accounts/AuthSubRequest?' + params)


def main():
  application = webapp.WSGIApplication([
    ('/', RemoteConsole), # for logged-in administrators, a Python console
    ('/post', RemoteConsole), # target for the console
    ('/group', IncomingGroupList), # uploaded group list
    ('/member/.*', MemberCheck),   
    ('/_ah/mail/answer@ceduauth.appspotmail.com', EmailAnswer),
    ('/auth', AuthSub)],
    debug=True)
  wsgiref.handlers.CGIHandler().run(application)


if __name__ == '__main__':
  main()
